﻿using System;
using System.Collections.Generic;
using Microsoft.Practices.EnterpriseLibrary.Caching;
using Microsoft.Practices.EnterpriseLibrary.Caching.Expirations;
using System.Configuration;


namespace BizElements.BusinessLayer
{
    /// <summary>
    /// Utility/facade class which wraps and extends common Ms Caching Application Block functionalities when caching global, application-level data.
    /// </summary>
    /// <remarks>Upgrades Ms Caching Application Block by introducing global item categories. Shares
    /// CacheManager instance and categories with <see cref="UserCache"/>.</remarks>
    public static class GlobalCache
    {
        #region Initialization and configuration.

        static int defaultAbsoluteExpirationInMinutes = 500;
        static ICacheManager cache;

        static GlobalCache()
        {
            cache = CacheFactory.GetCacheManager();
            int? cfgExpiresAfter = NumberParser.ParseInt(ConfigurationManager.AppSettings["BizElements.BusinessLayer.GlobalCache.DefaultAbsoluteExpirationInMinutes"]);

            if (cfgExpiresAfter.HasValue && cfgExpiresAfter.Value > 0)
                defaultAbsoluteExpirationInMinutes = cfgExpiresAfter.Value;
        }

        /// <summary>
        /// Gets or sets default absolute expiration in minutes.
        /// </summary>
        /// <value>Absolute expiration used by <see cref="SetData(string, object, string[])"/> method. Default is 500. Cannot be less than 1.</value>
        /// <exception cref="ArgumentException">Generated if a value less than 1 is set.</exception>
        /// <remarks>Default value may be set in the <b>appSettings</b> section of application's configuration file.
        /// <code>
        /// <add key="BizElements.BusinessLayer.GlobalCache.DefaultAbsoluteExpirationInMinutes" value="500"/>
        /// </code>
        /// </remarks>
        public static int DefaultAbsoluteExpirationInMinutes
        {
            get { return defaultAbsoluteExpirationInMinutes; }
            set
            {
                if (value < 1)
                    throw new ArgumentException("value", "Default absolute expiration may not be less than 1 minute.");

                defaultAbsoluteExpirationInMinutes = value;
            }
        }

        /// <summary>
        /// Indicates whether the cache is disabled in configuration file.
        /// </summary>
        /// <value><b>true</b> indicates that cache is disabled. Default is <b>false</b>.</value>
        /// <remarks>Value is set in the <b>appSettings</b> section of application's configuration file.
        /// <code>
        /// <add key="BizElements.BusinessLayer.GlobalCache.CachingDisabled" value="true"/>
        /// </code>
        /// </remarks>
        public static readonly bool CachingDisabled = NumberParser.IsTrue(ConfigurationManager.AppSettings["BizElements.BusinessLayer.GlobalCache.CachingDisabled"]);

        #endregion

        #region Category management.

        // Cache items can be categorized. If a category is changed you can remove all category items at once.
        // Both global and user cache items can belong to the same category. A cache item can belong to more 
        // than one category. When an item is removed from cache it should also be removed from all categories.

        // Tracks categories and all items they contain. Dictionary value is another dictionary so that
        // the item can be quickly removed from the category (O(1) algorithmic complexity). Sub-dictionary's
        // key represents a cache key. Sub-dictionary's value is always null.
        // Categories are never removed from the dictionary. Only the items they contain are removed.
        static Dictionary<string, Dictionary<string, object>> categoriesAndItems = new Dictionary<string, Dictionary<string, object>>();

        // Tracks cache keys and categories to which they belong. If a cache item is not categorized it shouldn't
        // be registered in this dictionary. Dictionary values represent an array of categories from which
        // the item must be removed (see categoriesAndItems dictionary).
        static Dictionary<string, string[]> itemsAndCategories = new Dictionary<string, string[]>();

        private static Dictionary<string, object> GetCategoryItems(string category)
        {
            Dictionary<string, object> items;
            bool shouldAdd = !categoriesAndItems.TryGetValue(category, out items);
            if (shouldAdd)
            {
                lock (categoriesAndItems)
                {
                    // Categories are never removed from the dictionary. Only the items they contain are removed.
                    // So only the Add/Set operation must be synced (thread safe).
                    // All other operations wait until new category is added.   
                    items = new Dictionary<string, object>();
                    categoriesAndItems[category] = items;
                }
            }

            return items;
        }

        private static void AssignItemToCategory(string category, string itemCacheKey)
        {
            Dictionary<string, object> categoryItems = GetCategoryItems(category);
            bool shouldAdd = !categoryItems.ContainsKey(itemCacheKey);
            if (shouldAdd)
            {
                lock (categoryItems)
                {
                    // Don't lock other categories.
                    // categoryItems dictionary never dissapears. Only its entries are removed/inserted.
                    categoryItems[itemCacheKey] = null;
                }
            }
        }

        private static void RemoveItemFromCategory(string category, string itemCacheKey)
        {
            Dictionary<string, object> categoryItems = GetCategoryItems(category);
            if (categoryItems.Count > 0)
            {
                lock (categoryItems)
                {
                    // Don't lock other categories. All other operations for this category wait until item has been removed.
                    // categoryItems dictionary never dissapears. Only its entries are removed/inserted.
                    categoryItems.Remove(itemCacheKey);
                }
            }
        }

        internal static void CategorizeItem(string itemCacheKey, string[] globalCategories)
        {
            // Cannot change categories for existing item.
            if (globalCategories.Length > 0 && !itemsAndCategories.ContainsKey(itemCacheKey))
            {
                lock (itemsAndCategories)
                {
                    // Lock items' dictionary while adding NEW item.
                    itemsAndCategories[itemCacheKey] = globalCategories;
                }

                foreach (string category in globalCategories)
                    AssignItemToCategory(category, itemCacheKey);
            }
        }

        private static string[] GetItemCategories(string itemCacheKey)
        {
            string[] categories;
            itemsAndCategories.TryGetValue(itemCacheKey, out categories);
            return categories;
        }

        /// <summary>
        /// Removes all items which belong to the specified categories from global and user cache.
        /// </summary>
        /// <param name="categories">Categories whose items are to be removed from cache.</param>
        /// <remarks><para>Categories which do not exist are ignored.</para>
        /// <para>Items which belong to more than one category are removed from other categories, too.</para>
        /// </remarks>
        public static void RemoveCategories(params string[] categories)
        {
            foreach (string cat in categories)
                RemoveCategory(cat);
        }

        /// <summary>
        /// Removes all items which belong to the category from global and user cache
        /// </summary>
        /// <param name="category">Category whose items are to be removed from cache.</param>
        /// <remarks><para>If no category exists with that key, this method does nothing.</para>
        /// <para>Items which belong to more than one category are removed from other categories, too.</para>
        /// </remarks>
        public static void RemoveCategory(string category)
        {
            bool categoryExists = categoriesAndItems.ContainsKey(category);
            if (!categoryExists)
                return;

            Dictionary<string, object> categoryItems = GetCategoryItems(category);
            if (categoryItems == null || categoryItems.Count == 0)
                return;

            // Dictionary will get modified during removal. Copy items to a new collection.
            List<string> itemCacheKeysToRemove = new List<string>(categoryItems.Count);
            foreach (KeyValuePair<string, object> item in categoryItems)
                itemCacheKeysToRemove.Add(item.Key);

            // Reduce some looping and number of lock attempts by clearing large categories.
            bool clearGivenCategoryFirst = (categoryItems.Count > 5);
            if (clearGivenCategoryFirst)
            {
                lock (categoryItems)
                {
                    // Don't lock other categories.
                    // categoryItems dictionary never dissapears. Only its entries are removed/inserted.
                    categoryItems.Clear();
                }
            }

            foreach (string itemCacheKey in itemCacheKeysToRemove)
            {
                // RemoveScavengedItemFromGlobalCategories automatically handles the removal of categorized 
                // items. No need to manually call RemoveCacheItemFromAllCategories method.
                cache.Remove(itemCacheKey);
            }
        }

        private static void RemoveCacheItemFromAllCategories(string itemCacheKey)
        {
            // Remove from all categories.
            bool isCategorized = itemsAndCategories.ContainsKey(itemCacheKey);
            if (!isCategorized)
                return;

            string[] categories = GetItemCategories(itemCacheKey);
            lock (itemsAndCategories)
            {
                itemsAndCategories.Remove(itemCacheKey);
            }

            if (categories != null && categories.Length > 0)
            {
                foreach (string cat in categories)
                    RemoveItemFromCategory(cat, itemCacheKey);
            }
        }

        internal class RemoveScavengedItemFromGlobalCategories : ICacheItemRefreshAction
        {
            public void Refresh(string removedKey, object expiredValue, CacheItemRemovedReason removalReason)
            {
                GlobalCache.RemoveCacheItemFromAllCategories(removedKey);
            }
        }

        #endregion

        #region CacheManager wrapper.

        private static string CreateKey(string key)
        {
            if (string.IsNullOrEmpty(key))
                throw new ArgumentException("key", "Null reference or empty string is not a valid key value.");

            return "GlobalCache_" + key;
        }

        /// <summary>
        /// Returns the value associated with the given key.
        /// </summary>
        /// <param name="key">Key of item to return from cache.</param>
        /// <returns>Value stored in cache or null if cache doesn't contain the specified key.</returns>
        public static object GetData(string key)
        {
            return cache.GetData(CreateKey(key));
        }

        /// <summary>
        /// Adds new CacheItem to cache.
        /// </summary>
        /// <param name="key">Identifier for this CacheItem. Null or empty string is not allowed.</param>
        /// <param name="value">Value to be stored in cache. May be null.</param>
        /// <param name="globalCategories">Optional list of global cache item categories to which the new item 
        /// belongs to. When a category is removed, all items it contains are removed, too.</param>
        /// <remarks><para>Adds new CacheItem to cache. If another item already exists with the same key, 
        /// that item is removed before the new item is added. If any failure occurs during this process, 
        /// the cache will not contain the item being added.</para>
        /// <para>Items added with this method expire absolutely after period defined by 
        /// <see cref="DefaultAbsoluteExpirationInMinutes"/> property.</para></remarks>
        public static void SetData(string key, object value, params string[] globalCategories)
        {
            SetData(key, value, defaultAbsoluteExpirationInMinutes, false, globalCategories);
        }

        /// <summary>
        /// Adds new CacheItem to cache.
        /// </summary>
        /// <param name="key">Identifier for this CacheItem. Null or empty string is not allowed.</param>
        /// <param name="value">Value to be stored in cache. May be null.</param>
        /// <param name="minutes">Time in minutes after which the cache item will expire. May not be less than 1 minute.</param>
        /// <param name="sliding">Indicates whether sliding or absolute time expiration schema is used for this item.</param>
        /// <param name="globalCategories">Optional list of global cache item categories to which the new item 
        /// belongs to. When a category is removed, all items it contains are removed, too.</param>
        /// <remarks><para>Adds new CacheItem to cache. If another item already exists with the same key, 
        /// that item is removed before the new item is added. If any failure occurs during this process, 
        /// the cache will not contain the item being added.</para></remarks>
        public static void SetData(string key, object value, int minutes, bool sliding, params string[] globalCategories)
        {
            if (CachingDisabled)
                return;

            if (minutes < 1)
                throw new ArgumentException("minutes", "Expiration period may not be less than 1 minute.");

            ICacheItemExpiration expiration;
            if (sliding)
                expiration = new SlidingTime(new TimeSpan(0, minutes, 0));
            else
                expiration = new AbsoluteTime(new TimeSpan(0, minutes, 0));

            ICacheItemRefreshAction autoRemoveFromCategories = (globalCategories.Length > 0)
                ? new GlobalCache.RemoveScavengedItemFromGlobalCategories() : null;

            string itemCacheKey = CreateKey(key);
            cache.Add(itemCacheKey, value, CacheItemPriority.Normal, autoRemoveFromCategories, expiration);
            CategorizeItem(itemCacheKey, globalCategories);
        }

        /// <summary>
        /// Removes the given item from the cache.
        /// </summary>
        /// <param name="key">Key of item to remove from cache.</param>
        /// <remarks><para>If no item exists with that key, this method does nothing.</para>
        /// <para>Item is also removed from all categories it any were specified.</para></remarks>
        public static void Remove(string key)
        {
            // RemoveScavengedItemFromGlobalCategories automatically handles the removal of categorized 
            // items. No need to manually call RemoveCacheItemFromAllCategories method.
            string itemCacheKey = CreateKey(key);
            cache.Remove(itemCacheKey);
        }

        /// <summary>
        /// Returns true if key refers to a non-null item currently stored in cache.
        /// </summary>
        /// <param name="key">Key of item to remove from cache.</param>
        /// <returns>True if item referenced by key is in the cache.</returns>
        public static bool Contains(string key)
        {
            object item = GetData(key);
            return (item != null);
        }

        #endregion

        #region Flush.

        /// <summary>
        /// Removes all items from the cache.
        /// </summary>
        public static void Flush()
        {
            FlushLocal();
        }

        /// <summary>
        /// Removes all items from the cache.
        /// </summary>
        /// <param name="flushCacheInRemoteDomain">Indicates whether to flush cache in the remote domain, too.</param>
        public static void Flush(bool flushCacheInRemoteDomain)
        {
            FlushLocal();
            if (flushCacheInRemoteDomain && RpcHelper.MethodRemoter.ImplementsRemoting)
                RpcHelper.InvokeStaticMethod(typeof(GlobalCache), "FlushLocal");
        }

        private static void FlushLocal()
        {
            // Possible issue - maybe categoriesAndItems shouldn't disband categories, instead it may be better to clear each category.
            // Possible dead lock since we are trying to lock everything before flushing.
            lock (cache)
            {
                lock (categoriesAndItems)
                {
                    lock (itemsAndCategories)
                    {
                        cache.Flush();
                        if (cache.Count == 0)
                        {
                            categoriesAndItems.Clear();
                            itemsAndCategories.Clear();
                        }
                    }
                }
            }
        }

        #endregion
    }
}